Day 23 的文章講到如果多執行緒共用同一個記憶體資源,且至少一個執行緒有寫入行為的話,會導致資料競爭,跑出不在預期中的結果,本篇文章就要來探討解決方法。
Mutex 的全名是 mutual exclusion(互斥),指當某一個執行緒把持著特定資源時,就把該資源上鎖,其他執行緒要等該執行緒把資源釋出後才能使用。
由 Day 23 文章延伸,傳統上鎖的寫法如下:
#include <iostream>
#include <thread>
#include <mutex> // 引入互斥鎖標頭檔
int counter = 0;
std::mutex mtx; // 宣告一個全域互斥鎖 mutex 物件
void addOneManyTimes() {
mtx.lock(); // 為「臨界區段」上鎖,僅開放一個執行緒入內
for (int i = 0; i < 100000; ++i) {
++counter;
}
mtx.unlock(); // 為「臨界區段」解鎖,使其他等待中的執行緒也能入內
}
int main() {
std::thread t1(addOneManyTimes);
std::thread t2(addOneManyTimes);
t1.join();
t2.join();
std::cout << "counter = " << counter << '\n';
return 0;
}
上鎖與解鎖之間的區塊稱為「臨界區段(critical section)」,也就是存取共用資源、但同一時間不允許複數個執行緒同時操作該資源的區塊。
{
mtx.lock(); // 上鎖
// 臨界區段 critical section
mtx.unlock(); // 解鎖
}
進入臨界區段前以 mtx.lock(); 上鎖、離開臨界區段時再以 mtx.unlock(); 解鎖。
但這樣寫其實有個風險:如果臨界區段邏輯複雜,出現 return 或因為錯誤等因素跳出 {} 區塊,那 mtx.unlock(); 就永遠不會執行到,意即永遠不會解鎖。
lock_guard 避免忘記解鎖從 C++ 11 起,可以把互斥鎖丟給 std::lock_guard 管理,當控制流程離開 {} 就會自動解鎖,其語法為:
std::lock_guard<std::mutex> name(mutex);
name:為這個鎖取的名字。mutex:使用的互斥鎖種類。所以上述範例可以改成:
std::mutex mtx; // 使用的互斥鎖物件
// ...
void addOneManyTimes() {
std::lock_guard<std::mutex> lock(mtx); // 新的上鎖方法,取用 mtx 物件、命名為 lock
// 臨界區段 critical section
for (int i = 0; i < 100000; ++i) {
++counter;
}
} // 離開 {} 時會自動解鎖,不用另外寫 mtx.unlock()
比 lock_guard 更強大的還有 unique_lock,不只一樣會在控制流程離開 {} 就自動解鎖,還能手動調控上鎖與解鎖的時間,當程式邏輯複雜時,有更高的操作彈性,詳細說明可參考 C++ 文件。
在 Day 23 文章中有提到資料競爭的原因,為兩執行緒都有以下三步驟:
任一執行緒都沒有等另一個執行緒完成寫入後,就以讀取到的舊值運算,導致有執行緒的第 2. 步被吃掉。
那為何不把這三步都合併成「一個不可拆分的步驟」?這樣如果一執行緒沒有做完三步驟,另一執行緒就沒辦法讀到共用變數 counter,自然也就不會造成資料競爭。
自 C++ 11 起,讓一變數具備原子特性的語法為:
std::atomic<type> name(initial_value);
其中:
type :該變數資料型別。name :該變數名稱。initial_value :該變數起始值。從 Day 23 範例延伸,以原子操作改寫的程式碼如下:
#include <iostream>
#include <thread>
#include <atomic> // 引入原子操作標頭檔
std::atomic<int> counter(0); // 型別為 int 的變數 counter 初始化為 0,且具備原子特性
void addOneManyTimes() { // 不用特別上鎖
for (int i = 0; i < 100000; ++i) {
++counter; // 每次都要完成讀取、+1、寫入三步驟,一氣呵成、不可分割
}
}
int main() {
std::thread t1(addOneManyTimes);
std::thread t2(addOneManyTimes);
t1.join();
t2.join();
std::cout << "counter = " << counter << '\n';
return 0;
}
這招相對複雜一些,要另外令一個作為「旗標(flag)」的新變數,兩執行緒透過旗標的值決定是否執行。
以 Day 23 文章範例來說,可先加上名為 ready 的旗標,旗標的值為 false 時繼續等待、為 true 時再開始執行,整體程式碼如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable> // 加上條件變數標頭檔
int counter = 0;
bool ready = false; // 旗標初始值為 false
std::mutex mtx; // 互斥鎖
std::condition_variable cv; // 條件變數,讓執行緒等待或接收通知
void addOneManyTimes() { // 離開這個區塊時,mutex 會自動解鎖
{
std::unique_lock<std::mutex> lock(mtx); // 先把這區塊鎖住
std::cout << "Thread waiting...\n";
cv.wait(lock, [] { return ready; }); // 當 ready == false 時,就等待;ready == true 時,就跳出此區塊往下執行 ++counter
} // 離開這個區塊時,mutex 會自動解鎖
// 用 mutex 保護 counter 變數,讓其完成指定次數的 ++counter
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
}
int main() {
std::thread t1(addOneManyTimes);
std::thread t2(addOneManyTimes);
{
std::lock_guard<std::mutex> lock(mtx); // 在此區塊上鎖保護 ready 旗標
ready = true; // 把旗標改成 true,才可以讓新執行緒開始工作
std::cout << "Main thread sends notification.\n";
}
cv.notify_all(); // 喚醒所有正在等待的執行緒
t1.join();
t2.join();
std::cout << "counter = " << counter << '\n';
return 0;
}
上述程式碼的執行順序如下:
main() 內,t1 與 t2 兩個新執行緒都去執行 addOneManyTimes() 函數:std::thread t1(addOneManyTimes);
std::thread t2(addOneManyTimes);
t1 與 t2 檢查 ready 旗標,若為 false 則等待被喚醒、若為 true 則往下執行:// 在 t1 與 t2 各自執行的 addOneManyTimes() 函數中
{
std::unique_lock<std::mutex> lock(mtx); // 先把這區塊上鎖,保護 ready 旗標
std::cout << "Thread waiting...\n";
cv.wait(lock, [] { return ready; }); // ready 若回傳 false 會先等待,等待期間暫時解鎖,讓 ready 可被其他執行緒改動,直到被喚醒後再上鎖、判斷 ready 新值
// ready 若回傳 true 則不再等待、往下執行
} // 退出時 {} 會解鎖
main() 把旗標改為 true:{
std::lock_guard<std::mutex> lock(mtx); // ready 為共享變數,因此改變旗標前先上鎖
ready = true; // 把旗標改為 true
std::cout << "Main thread sends notification.\n";
} // 改完旗標、跳出此區塊後自動解鎖
cv.wait(...) 而正等待中的執行緒:cv.notify_all();
值得留意的是,如果 t1 與 t2 沒有早於主執行緒先跑,那 t1 與 t2 讀取到的 ready 值直接就是 true,沒有進入等待狀態就進入 ++counter 運算,不需要等主程式把 ready 從 false 變為 true 後被喚醒。
ready 旗標變為 true 時,進到下個區塊進行運算;因為運算時使用的 counter 為共享變數,因此還會再上鎖一次:for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 上鎖保護 counter 變數
++counter;
}
t1 跟 t2 誰先被喚醒不一定,但因為有 lock_guard 保護,所以可確保不會互相影響 counter 的運算結果,最後就是透過:
t1.join();
t2.join();
等兩個新執行緒做完,再顯示 counter 經兩執行緒運算後的最終結果。含前面 cout 的整體顯示結果如下:
Thread waiting...
Thread waiting...
Main thread sends notification.
counter = 200000
如 Day 22 文章所說,執行緒的執行順序並不一定,因此也可能是下列兩種結果:
Main thread sends notification.
Thread waiting...
Thread waiting...
counter = 200000
Thread waiting...
Main thread sends notification.
Thread waiting...
counter = 200000
無論如何,counter 的值一定是 200000,且一定是最後一行。